Skip to content

Add Private Media feature: attachments private by default#458

Open
mikelittle wants to merge 15 commits intomasterfrom
issue-162-default-private-uploads-2
Open

Add Private Media feature: attachments private by default#458
mikelittle wants to merge 15 commits intomasterfrom
issue-162-default-private-uploads-2

Conversation

@mikelittle
Copy link
Contributor

Summary

  • Implements the Private Media feature which makes uploaded media attachments private by default on S3-hosted sites
  • Attachments only become publicly accessible when used in published content, marked as a site icon, flagged as legacy, or manually overridden
  • Includes media library UI (visibility column, row actions, bulk actions, modal sidebar), WP-CLI commands, and user documentation
  • Fresh implementation under Altis\Media\Private_Media namespace (not extending the existing issue-162-default-private-uploads branch)

Key components

  • Visibility logic — priority-based public/private determination (force-private > force-public > used-in-published > legacy > site-icon > default private)
  • Post lifecycle — hooks into transition_post_status and save_post to track publish/unpublish transitions and detect removed attachments
  • Content parser — regex extraction of attachment IDs/URLs from all Gutenberg block formats
  • Sanitisation — strips AWS signing parameters from content on save
  • Signed URLs — preview support for private images in draft/future posts
  • Query compatibilitypre_get_posts filter adds publish and private to attachment queries (always active, even when feature disabled)
  • Capability handlingmap_meta_cap filter grants read_post for private attachments to users with upload_files capability
  • Media library UI — visibility column, Make Public/Private row actions, bulk Set Visibility action, modal sidebar visibility dropdown
  • WP-CLImigrate, set-visibility, fix-attachments commands with --dry-run support
  • User documentationdocs/private-media.md with screenshots and configuration guide

Files

  • 13 PHP source files in inc/private_media/
  • 2 asset files (JS/CSS) in assets/
  • 3 integration point changes (load.php, inc/namespace.php, composer.json)
  • 9 integration test files (68 tests, 140 assertions)
  • 1 acceptance test file (4 browser-based UI tests)
  • User documentation with screenshots

Test plan

  • 68 integration tests passing (visibility, content parsing, post lifecycle, sanitisation, signed URLs, query compat, site icons, overrides)
  • 4 acceptance tests passing (upload defaults private, Make Public/Private row actions, Remove Override)
  • Manual verification: upload media, verify private status, publish post with image, verify attachment becomes public, unpublish, verify returns to private
  • Manual verification: test as Author role — media library browsing, featured image setting, content editing with private attachments
  • Add remaining screenshots to documentation (bulk confirmation, modal sidebar, post actions, success notice)

🤖 Generated with Claude Code

mikelittle and others added 4 commits March 11, 2026 16:30
Implement the Private Media feature which makes uploaded media attachments
private by default. Attachments only become publicly accessible when used
in published content, marked as a site icon, flagged as legacy, or manually
overridden via the UI/CLI.

Key components:
- Visibility logic with priority-based public/private determination
- Post lifecycle hooks to track publish/unpublish transitions
- Content parser to extract attachment references from block content
- AWS signing parameter sanitisation on save
- Signed URL support for draft/preview contexts
- Query compatibility layer (always active) for private post_status
- map_meta_cap filter so authors/editors can access private attachments
- Media library UI: row actions, bulk actions, modal visibility dropdown
- WP-CLI commands: migrate, set-visibility, fix-attachments
- 68 integration tests with S3 ACL mocking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a "Visibility" column to the media library list table showing
Private/Public status with forced override indicators. Add acceptance
tests for the media library UI: upload defaults to private, Make Public
and Make Private row actions, and Remove Override action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add docs/private-media.md explaining the feature from a user perspective:
how uploads are private by default, how they become public when content
is published, how to manage visibility via quick actions, bulk actions
and the media editor sidebar, and configuration options for developers.

Includes screenshots of the media library visibility column and row
actions, with placeholders for additional screenshots to be added
manually (bulk confirmation, modal sidebar, post actions, success notice).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
mikelittle and others added 11 commits March 12, 2026 15:50
1. Add per-request static cache for attachment privacy checks to avoid
   repeated DB lookups when S3 Uploads calls the filter for every URL
   of every image size (~200 calls per media library page load).

2. Route signed image URLs through tachyon_url() in REST content.raw
   so X-Amz-* params get bundled into a presign query parameter.
   Without this, the browser hits CloudFront directly with S3 signing
   params which it cannot validate (host mismatch), resulting in 404.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Normalize HTML-encoded ampersands (&amp;) before parsing query strings
- Restore original separator style after filtering AWS parameters
- Add tests for HTML-encoded ampersands in URLs with single and multiple non-AWS params
`set_attachment_visibility()` now uses a direct `$wpdb->update()` + `clean_post_cache()` instead of `wp_update_post()`.
This avoids triggering nested hook cascades (image srcset generation, etc.) that cause the OOM error when called from
within the parent post's transition_post_status handler.
The S3 ACL update and CDN cache purge still run normally via their own calls.
Two root causes prevented attachments from transitioning to public on
publish and AWS params from being stripped from stored content:

1. wp_insert_post_data receives slashed content (\" instead of "),
   so the sanitisation regex never matched src attributes. Fixed by
   wrapping with wp_unslash()/wp_slash().

2. HM\Media\Cropper's filter_attachment_meta_data uses a static cache
   on wp_get_attachment_metadata that doesn't invalidate when we update
   metadata. After add_post_reference() saved the used_in_published_post
   key, the subsequent check_attachment_is_public() read returned stale
   cached data without our key. Fixed by passing $unfiltered=true to
   all wp_get_attachment_metadata() calls in our visibility functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On the Altis platform, Tachyon URLs omit the uploads/ prefix
(e.g. /tachyon/2026/03/img.jpg instead of /tachyon/uploads/2026/03/img.jpg).
The regex only matched the uploads/ variant, so clean_url returned the
Tachyon URL unchanged. This caused replace_private_urls() to fail to
sign URLs for previews and REST responses after sanitisation stripped
the original presign params.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
replace_private_urls() was passing the content-parsed URL (Tachyon or
canonical WordPress path) to add_s3_signed_params_to_attachment_url(),
but S3 Uploads' get_s3_location_for_url() can only resolve S3 bucket
URLs or wp_upload_dir() base URLs. The content-parsed URL didn't match
either, so signing silently failed and previews showed broken images.

Now uses wp_get_attachment_url() (which returns the S3 URL) with query
params stripped, so the S3 location can be resolved and signing works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Logs attachment discovery, signing resolution, and str_replace results
to trace why preview signing isn't working on the deployed server.
To be removed after debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tachyon_url() on an already-signed URL produced a malformed URL with
two '?' characters (e.g. ?presign=...?resize=1920,1285). Now we call
tachyon_url() on the unsigned base URL first (to get proper sizing
params), then manually append the S3 signing params as a presign
query parameter with correct & separator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tachyon's filter_the_content runs at priority 999999 on the_content,
rewriting image URLs and adding resize/fit params. Our preview signing
was at priority 999 (before Tachyon), so Tachyon stripped the presign
params we added.

Now runs at priority 1000000 (after Tachyon). When the content URL is
already a Tachyon URL (with resize params from Tachyon), we use it as
the base and append presign via add_query_arg, preserving both the
resize params and the S3 signing params. In REST API context (where
Tachyon hasn't processed the content), we build the Tachyon URL first
via tachyon_url() then append presign.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant